Add SSH certificate authentication for targets, issued by Vault - #2397
Add SSH certificate authentication for targets, issued by Vault#2397janisdombr wants to merge 47 commits into
Conversation
|
Will be happy to see this merged since it's the only deployment blocker for us due to security concerns. |
050912e to
1578fc6
Compare
|
Went through this again against the current head ( Real progress since the last round: the AWS static-credential gap is now a genuine, well-designed fix, Three things from the last round are still open, each with a concrete fix. Vault issuer errors reaching the SSH client are still truncated to 256 characters rather than sanitized by content, so a policy or role name can survive that length. The fix is a
Also, The bigger thing: stepping back from individual lines, there's a structural question worth resolving before this merges. Under the current design, Vault can't distinguish one target/session from another, One more thing worth knowing before this merges: #2185 also adds Vault integration (a different problem, relocating static secrets into KV rather than issuing certs, but it collides mechanically with this PR in several places, workspace crate registration, This doesn't mean the direction is wrong. Ephemeral, non-stored credentials is the right fix for a real, long-standing gap, and the mechanics here are solid. |
|
Opened #2400 with a concrete design for the authorization question from the review above, rather than posting the whole thing inline here. Short version: the core piece there, identity-templated Vault roles plus per-session scoped child tokens, so Vault verifies the principal instead of trusting what Warpgate asserts, belongs in this PR before merge, not a fast-follow. Without it, this design can plausibly have a worse worst-case blast radius than what it replaces (fleet-wide, non-revocable access versus today's bounded-to-stored-credentials), so it's not a good candidate for shipping as a documented limitation. The remaining hardening in the issue (full IdP-verified non-repudiation, host-binding, revocation) is genuinely separable follow-up work once that baseline is in. |
c00a253 to
bc0fb04
Compare
|
@theredspoon Thank you for the follow-up review!
|
|
Went through the current head (
let http = reqwest::Client::builder().timeout(config.timeout).build()?;That leaves reqwest's default policy in place, which follows redirects and only strips Unbounded buffering + panic in error-body truncation
let body = response.text().await.unwrap_or_default();
let max_len = 256;
let body = if body.len() > max_len {
format!("{}... (truncated)", &body[..max_len])
} else {
body
};
Smaller items
|
|
@theredspoon both fixed, thanks. Redirects are refused outright now, which covers the metadata calls too. The error body is read chunk-wise with a 256-byte cap and truncated lossily, so a split character can't panic it. Chasing that one, I found the success path had Zeroization is end-to-end now: the login body goes through typed structs instead of a The stub validators actually validate now — decoded AWS payload, full Azure coordinates, JWT shape, GCP audience — and have tests of their own. You were right that they were asserting nothing. A pass over the rest turned up a few more: One I'd like your view on: a role with 425fb05. |
4d8e294 to
dd06172
Compare
|
Confirmed everything in On The "hostile Vault already has target access anyway" framing undersells this. The realistic case day to day is more mundane than either: a legitimate, uncompromised Vault, an operator who copies or templates a role with Suggest: default-reject any critical option. Per-target opt-in as a named allow-list of expected option keys, not a bare boolean, and for Two more, from this round:
expires_at: (auth.lease_duration > 0).then(|| {
Instant::now() + Duration::from_secs(auth.lease_duration).saturating_sub(TOKEN_EXPIRY_MARGIN)
}),
IPv6 loopback is misclassified as insecure. One more, lower priority: the AWS path is the one exception to end-to-end zeroization. |
|
Ran a wider architectural sweep across the codebase, not just this PR's diff, then went back and verified every proposed fix against the real code and this PR's own existing patterns. Certificate minting via the host-key-check admin endpoint
The fix needs to be a deterministic signal, not a race. Separately: Vault config doesn't hot-reload
Cloud metadata tokens can transit an ambient proxy
Checked against Vault's actual server source ( Three real things remain from that investigation:
Response-wrapped AppRole secret IDs need the unwrapped value cached
Response wrapping protects one-time delivery of the secret ID, it doesn't force single-use of the secret ID itself. Please resolve by caching the unwrapped secret ID, keyed on the raw file content, reusing it while the file is unchanged and only re-unwrapping when the content actually changes (an operator writing a fresh wrapping token). Keep a distinct error for the real failure case, an unwrap attempt (first use, or after a detected change) that fails because the token is stale or already consumed: Lower priority
|
409e2e5 to
4b825c1
Compare
|
Both rounds are in commit 409e2e5. @theredspoon On critical options you changed my mind. "A hostile Vault already has target access" conflated two different capabilities: force-command isn't extra access, it's laundered attribution, and the target's own log is the thing this feature exists to make trustworthy. The role-write-without-sign path settles it. So: default-reject, per-target allow-list of names with optional pinned values, and the refusal reaches the connecting user rather than a log nobody watches. Everything else landed as you described it checked_add on the lease, url::Host for IPv6, Zeroizing on the AWS path, the allow_user_key_ids message, valid_principals checked with Two places I'm weaker than I'd like, said plainly: The host-key check I took the explicit-intent route, a dedicated RCCommand::CheckHostKey that returns before authenticate_session, final hop only. What I can demonstrate is the leak: revert it and my test fails on connections still open after the request returned. What I could not reproduce is the certificate actually being minted the leaked task stalls before signing in my setup, over a 5s window. That assertion is a guard, not evidence; your 310.6s measurement is the real data point. If you can share how you drove it to sign I'll make it deterministic. The JoinHandle I didn't thread one through. CheckHostKey ends the task, and the admin caller sends an explicit abort afterwards, scoped so ServerSession's graceful disconnect stays untouched. Two mechanisms rather than the third you Tests are 15 Rust unit and 57 integration, up from 12 and 48. Each new one was verified by breaking the code it defends including one that didn't fail on the first attempt, the valid_principals case, which rejects that certificate too. Rewritten to assert who did the refusing. |
Warpgate authenticates to an SSH target with a short-lived OpenSSH user certificate signed on demand by HashiCorp Vault, instead of a private key it stores. The ephemeral keypair is generated per connection and never persisted, so a compromise of the Warpgate host yields nothing a target would accept. Targets trust the CA through TrustedUserCAKeys and need no authorized_keys. The certificate's key ID carries the Warpgate username and session UUID, so the target's own sshd log attributes a proxied session to a person rather than to the gateway. VaultAuth offers workload identity only — kubernetes, AppRole, AWS, Azure and GCP. Each reads its credential from a file or a metadata service, never from the config: a static Vault password would merely relocate the long-lived secret this feature exists to remove. Full compatibility with OpenBao is supported. Verified end to end against real infrastructure — AWS STS, a GCE instance, an Azure VM and a k3d cluster. tests/test_ssh_target_cert_auth.py runs against a stub issuer and needs neither Vault nor a cluster. Discussion: warp-tech#26 Special thanks to @theredspoon for the detailed test, OpenBao evaluation, and security recommendations.
- The admin host-key check ran on into authenticating to the target. On a certificate target that minted a real certificate and opened a real session nobody was attached to, held until the inactivity timeout, with a key ID naming no user. Now a dedicated RCCommand::CheckHostKey stops before authentication, on the final hop only so jump hosts still authenticate. - A certificate could arrive carrying critical options nobody asked for. A force-command there replaces what the user typed while keeping their own principal and key ID on the session, so the target's log attributes it to them. Write access to a Vault role is a lower bar than the right to sign with it, so this is the only place it can be caught. Refused by default; a target may name the options it expects and pin their values. - Nothing checked that the certificate named the account being reached. valid_principals is now verified against the target's username. - A response-wrapped AppRole secret ID was re-unwrapped on every login. A wrapping token is single-use, so every login after the first failed, as a generic denial. The unwrapped secret ID is now cached against the file content, and a genuine unwrap failure names the file and the fix. - lease_duration from Vault fed an unchecked Instant addition, so an oversized lease crashed the process on the login path. Now rejected as a bad response. - Cloud metadata tokens went through the same client as Vault, which honours HTTP_PROXY by default; GCE's hostname defeats a typical IP-based NO_PROXY. Metadata now uses a client built with no_proxy(). - The AWS login path was the one place credentials were not zeroized. - An IPv6 loopback Vault address was classified as a remote plaintext endpoint, because host_str renders it with brackets. - Editing the vault: section had no effect until a restart, alone among config sections. A VaultCell on a watch channel is rebuilt from run.rs; a configuration that fails to build keeps the working client. - A certificate Warpgate itself refused reported "SSH target rejected Warpgate's authentication request", naming the wrong party. It has its own error now, and the reason reaches the connecting user. - A role that forbids key IDs now produces a message naming allow_user_key_ids. Tests: 15 Rust unit and 57 integration, up from 12 and 48; each new one verified by breaking the code it defends. The stub models single-use wrapping tokens, without which the AppRole defect was invisible. Found by @theredspoon's review, which is worth more than the code it corrects.
c5dea27 to
58f831a
Compare
The stub in tests/ is fast and can be made to misbehave, but it only knows what we told it — and two of the defects found in review were invisible for exactly as long as it was the only witness. tests/vault_server.py runs the suite against a real HashiCorp Vault and a real OpenBao, reading requests back out of the server's own audit device, so the payload under assertion is the one the server received. Every behaviour the stub models is now pinned against both. Three defects came out of it: - Every login left a copy of the credential in freed memory. login_payload used serde_json::to_string, whose String grows as it is written and frees each smaller buffer without wiping it; Zeroizing only ever wipes the buffer that survives to the end. Size decides whether it shows: measured with a 4 KiB credential, which is what a Kubernetes service account token or a signed AWS header set actually is. Now serialized into a buffer reserved up front. - The certificate's key ID was never checked against the one requested. A certificate carrying a 64 KiB key ID authenticated normally. The target's sshd logs that field verbatim, and "the target's own log names the person" is the claim this path exists to deliver, so an issuer returning a different one breaks attribution silently. - The reason an authentication failed never reached the person connecting. ConnectionError::Authentication carried no detail; the reason went to the server log and the user got a fixed string. For a certificate refused because it is outside its validity window — the documented clock-skew hazard — that sends whoever is debugging it to check credentials that are fine. The variant now carries its reason and the certificate arm names the window. Also documented: OpenBao refuses to enable an audit device over the API, and its config stanza needs type, path and an options block — a top-level file_path is accepted with a warning and then ignored, which looks exactly like a working audit device that writes nothing. Tests: 16 contract tests across Vault and OpenBao (five versions under WARPGATE_VAULT_MATRIX=full), 8 for certificates a real issuer would never emit, 6 property tests over the validators, and 3 that watch the allocator to check the zeroization claim rather than trusting it.
58f831a to
d818090
Compare
|
Pushed d818090, rebased onto current main. This round came from building the test infrastructure rather than from reading the diff again. tests/vault_server.py runs the suite against a real Vault and a real OpenBao, reading requests back out of the server's own audit device, so
Also OpenBao refuses to enable an audit device over the API, and its config stanza needs Two CI gates are red and neither is from this branch:
I left both alone rather than touch unrelated files in a security PR. |
Three defects, found by reading other projects' advisories and by pointing two tools at this code that had not been used on it before. - A certificate naming more than the target account was accepted. The check asked whether the requested principal was among those returned; Vault returns the requested set verbatim or refuses, so anything extra means the answer did not come from this request. Each extra name is another account the target will accept the certificate for, chosen by whoever answered rather than by the operator, and under AuthorizedPrincipalsFile it need not resemble a username. Now required to be exactly the account asked for. This came from CVE-2024-7594, where an empty valid_principals yielded a certificate good for any user on the host, and CVE-2026-35414, where a comma inside a principal splits one name into two for one of sshd's checks and not the other. The second is also why the rule is "exactly one name" rather than "contains": it notes the attack works when the CA does not reject commas in what it is asked to sign, which is the check Warpgate already makes on the request side. - A certificate could write escape sequences to the connecting user's terminal. The refusal message quotes the critical option's name straight out of the certificate and is printed to the PTY, so a name containing \x1b[2J cleared their screen rather than appearing in the text. Certificate-derived strings are now quoted with {:?}. - The outbound SSH handshake had no bound of its own. A target that completes the TCP connection, sends a valid identification string and then goes silent held the gateway's task, socket and session slot until the *inbound* session's inactivity timeout fired — measured at 55s with that timeout set to 45s. That setting governs how long an idle interactive session may live and is legitimately raised to hours, every one of which extended this hold to match. Bounded now by a dedicated 30s deadline, with an error naming the stage so an operator is not sent to look at credentials. tests/hostile_ssh_server.py is new: six ways of being a bad SSH server, none of which needs Docker. The rest of the suite treats the target as honest, which is the one trust boundary nothing here had pushed on — and russh, which Warpgate is the client half of, has published pre-authentication panics reachable from the peer. Five of the six modes were survived without change. cargo mutants found the fourth problem, in the tests rather than the code: it replaced the error-body reader with one returning an empty string and everything still passed, because the assertions were all upper bounds. Ten mutants survived in that one function. The truncation marker is now pinned from both sides.
|
Pushed 6bd00e1. Three more defects, found by reading other projects' advisories and by pointing two tools at this code that had not been used on it before. A certificate naming more than the target account was accepted. The check asked whether the requested principal was among those returned. Vault returns the requested set verbatim or refuses, so anything extra means the answer did not come from this request and each extra name is another account the target will accept the certificate for, chosen by whoever answered rather than by the operator. Under This came out of two advisories rather than out of the diff: CVE-2024-7594, where an empty A certificate could write escape sequences to the connecting user's terminal. The refusal message quotes the critical option's name straight out of the certificate and is printed to the PTY, so a name containing The outbound SSH handshake had no bound of its own. A target that completes the TCP connection, sends a valid identification string and then goes silent held the gateway's task, socket and session slot until the inbound session's inactivity timeout fired measured at 55s with that timeout set to 45s. That setting governs how long an idle interactive session may live and is legitimately raised to hours, every one of which extended this hold to match. Bounded now by a dedicated 30s deadline, with an error that names the stage.
Checked and clean, for the record: russh 0.62.6 is current against all fourteen of its advisories, and CI is still red on |
|
Ran a final-gate pass with three independent reviewers plus direct verification against real sshd servers, since this round changed enough surface (the real-Vault/real-OpenBao test harness, the critical_options allow-list logic, the CheckHostKey command) to be worth a genuinely fresh look rather than re-confirming what's already fixed. Everything from the last round not mentioned below has been confirmed separately. Two real, previously-unflagged issues, plus a cluster of smaller ones. Host-key check returns the wrong key for any target behind a jump host Already independently reported and being fixed: issue #2412 and its open fix, PR #2413 ( What #2413 doesn't cover, since it's written against Related to that: no certificate gets minted for the jump host today, but that's not a construction guarantee the way it is for the final hop, it's the admin endpoint's abort winning a race against the SSH handshake, the same category of fragility Pinned critical options are only checked when the certificate actually carries them
Smaller items, roughly by severity
One more, separate from the above: the terminal-escape-sequence fix in Given how many of the above are tests passing without exercising what they claim to, worth doing your own adversarial pass over the test suite specifically, not just the production code, and writing down whatever gaps that turns up so they don't quietly regress later. |
The fix for the reserved-name collision moved any name equal to `admin-token` or `cluster-token` aside, and applied that to every name reaching the key ID — including the gateway's own. A session driven by the admin API token started reporting `warpgate:admin-token_:<session>`, so the fix written to make that string trustworthy changed it. Two guards caught it by failing their baseline, which is what a baseline is for. The two kinds of name are indistinguishable as strings — that is the defect, not an accident of this code — so the distinction is carried as data. `IdentityHint` is `Gateway` or `Person`; `key_id_field` does the colon substitution for both, and `user_key_id_field` adds the reserved-name substitution for names a person chose. `username()` was already `None` for exactly the two token variants, so the line was already drawn and only needed carrying. The test had checked that the substitution fires and nothing had checked that it does not fire where it must not. Both directions now.
|
Rebased on current Since the last push this branch went through six rounds of external review by @theredspoon - including one pass with three model providers in parallel - plus our own adversarial agents and a two-provider arrangement where one 115 distinct defects were raised. 109 are fixed with evidence, 2 are tracked upstream because they are upstream's code, 3 were withdrawn, and 1 falls outside the project's published threat model. Nothing is deferred. One of the two upstream items was the web-SSH host-key storage bug, reported separately and fixed by @Eugeny in fb66ff7; the fix is merged here. What the evidence is
46 of the 47 guards are measured discriminating on the tree as it stands. The artifact is The forty-seventh is Notable fixes in this round
Happy to walk through any of these, or to share the full review ledger and audit report if that would help review. LeftTwo minor fixes need to be done. I'll finish them tomorrow |
…eadline Three of these came from CI running the branch for the first time since the tests were written, and three from an independent verifier told to disbelieve everything I had claimed. Both found things a week of local work had not. A compiler crash dump was committed and pushed. `git add -A` took it; nobody looked. Removed, and .gitignore now refuses it. A test passed because it ran alone. The jump host in the host-key check was the file's shared fixture server, which earlier tests connect to — so its key was already trusted, no refusal happened, and the assertion failed the moment the suite ran in order. Both hops now get their own freshly started server. The guard verifier could never have caught this: it runs one named test at a time, which is exactly the condition under which the test passed. Validating a Vault role when a target is saved made an older test unsatisfiable — the API now refuses to create the target whose signing-path refusal that test proved. The test asserts the save-time refusal instead, and the composition it used to prove is proven where it still can be: a unit test that calls `sign_ssh_key` with a traversal role and asserts nothing left the process. Reading the code and seeing `validate_segment` at the top of that function is not the same as watching the request log stay empty. And the handshake-deadline guard, whose named test passed with the guard disabled. Three experiments, not an argument. With the resume mutated the test still finished in 37s, so the original bound fired and neither pause nor resume had run. Replacing the pause with an immediate error left every deadline assertion passing and broke only the ordinary connection at the end, so the branch runs for a real target and not for the fixture. The fixture mutes before NEWKEYS and russh does not call `check_server_key` until the exchange completes; letting NEWKEYS through trips strict-kex and the client disconnects in three seconds. That code is unreachable from an integration test. So the policy is now two named functions and the guard is anchored on the one that can be got wrong: the answer must put the target's own bound back, not something longer. That is weaker than an end-to-end proof, and it is stated as weaker. The end-to-end proof was never there — it was believed to be.
…ence Three guards named one test. An outside verifier read it and found the assertion was `target_key != jump_key` — "not the jump host's key", where the name claims "the target's key". Those coincide for a chain of two and stop coinciding for a chain of three, so the test proved less than it said. Splitting them turned out to be the real fix rather than a tidy-up: the guards need opposite starting conditions. "An untrusted jump host is refused" needs a jump host nobody has trusted; "the hop is chosen by identity" needs one that is trusted, or there is nothing to walk through. One test was setting up both worlds in sequence, which is why making the first half honest broke the second. The reported key is now compared against the key the target server was actually started with, type and base64 both. `start_ssh_server` records what it generated, so a test can name the host that answered instead of eliminating the one that did not. Both tests pass alone and in the file. That pair of runs is the check that matters here: the previous version passed alone and failed in the suite, and my first attempt at fixing it passed in the suite and failed alone. Neither run on its own would have caught either.
…nto feat/vault-ssh-certificate-auth
Upstream's warp-tech#2437 fixes the same defect this branch fixes — "check host key returns the jump host's key" — by a different design. Both were in the tree after the merge, and having both was worse than having either. Upstream identifies the hop by address: it carries the hop's host and port in `RCEvent::HostKeyReceived` and matches them in the admin endpoint. This branch identifies it by the target's id, in `connect_chain`, which also decides where the walk stops. The identity is the stronger key — two hops can present the same address, and the address cannot say which one the caller named — so the walk keeps deciding, and the event keeps carrying the address, which web-SSH uses. The address match in the endpoint is dropped, and it is worth saying why rather than leaving it as harmless redundancy. `resolve_ssh_chain` puts the asked-about target last, so the address and the id come off the same resolved hop: the address check can only fail where the identity gate has already failed. It is not a second opinion. And it had a cost — with it in place, both jump-host integration tests passed while `reports_host_key()` was disabled, because the address filter caught what the mutation released. A guard whose test cannot see it switched off is not measured. Upstream's `HostKeyUnknown` arm goes with it. It is unreachable here: the walk refuses an untrusted jump host at the hop and arrives as `ConnectionError::UntrustedJumpHost`. Keeping a "this is a jump host" comment on an arm no jump host can reach would misdescribe the code. Also from this merge: a staged Cargo.lock that named a `thiserror` version no package entry provided, which would have shipped a lockfile that does not resolve. Nothing in CI passes `--locked`, so nothing would have caught it. Verified after the merge: all 47 anchors present, unit tests 56 + 27 + 4 passing, and the clippy deny set clean. The 47-of-47 guard measurement was taken before this merge; the eight guards whose code it touches are being re-measured, and the report will say which number came from where.
|
@theredspoon - every item from your last round, numbered as you numbered them, with the commit that closed it. Three of the twelve were sharper than they looked and one of them was a real memory-disclosure defect I had argued against before measuring it; that argument was wrong and the measurement settled it. Round I, items I-1 … I-12 - all twelve closedI-1 · The E-4 fix carries the defect E-4 removed, one stage over. I-2 · The Vault token mutex is held across an operation with no time bound. I-3 · I-4 · The matrix names a discriminator that does not exist. I-5 · Two proptest properties are vacuous on the assertion they exist for. I-6 · Measured discriminator count. I-7 · The unattributed key ID loses a field rather than marking itself. I-8 · README documents three refusals; there are four. I-9 · The sanitiser is applied per call site, never at the sink. I-10 · I-11 · AWS log silencing covers one crate of three. I-12 · That extension then broke what it was protecting, and the fix is worth reading as a pair with it. The eleven you listed as not verified - all eleven triagedYou closed that round with eleven items marked "I did not verify, and they are not in the findings above". We wrote them into our ledger as prose rather than as rows, which meant they were not counted - and the round was then reported as fully assessed twice, by us. They have now all been checked.
What we found by ourselves after your roundYour round did not end the defects; it changed what we were willing to accept as evidence. Running the guards rather than asserting them found five more defects in the instruments, and the most useful findings since have come from a machine, from CI, and from an outside reader rather than from us.
On the merge with #2437, since it solves the same bug
Upstream identifies the hop by address, carrying host and port in The address match in the endpoint is dropped, and the reason is worth stating rather than leaving it as harmless redundancy. Upstream's The merge also carried a staged Where the evidence stands, and what you can check from the PR alone. 47 of 47 guards measured discriminating, in one run over one commit. That measurement was taken before the merge above; the eight guards whose code the merge touches are being re-measured, and I will give the two numbers separately rather than one figure for both trees. After the merge: all 47 anchors present, unit tests 56 + 27 + 4 passing, clippy deny set clean. Worth being straight about what that claim rests on, since you cannot verify it from this diff. Thank you for the round. Several of these were things we would not have found, and I-10 is one we had actively argued against. |
|
@janisdombr Great teamwork so far, thanks for working on this item with me. Ran the mutation matrix myself rather than working from the report, all 47 guards, two full runs plus a third targeted rerun on anything the first two disagreed on. Tool issues first, then results, then asks. Two real issues in One correction, no action needed: "the A/B mutation-testing runner... is not in the PR" isn't accurate. Results, all 47, and what each group means for you35 discriminate cleanly, stable across every run. No action needed on these. 11 guards' tests failed because of test-harness fragility, not application bugs. Please resolve both root causes.
All 11 confirmed directly, not guessed: fixing both turned every baseline-red and merge-touched guard green; for the 3 previously reported as "does not discriminate" or unstable across runs, re-running the guard's own mutation against a rebuilt binary with the fix applied gave a clean, deterministic A/B result. None of these are application defects, and none of the 3 misreported ones are real coverage gaps. All 11 guards, discriminator test, and how each was impacted
One real ask that fell out of chasing the sandbox issue. Please resolve by giving that endpoint's response a way to distinguish connection-establishment failure from an actual host-key rejection, even if the generic message stays for every other caller. The rest of the round, verified directly against code rather than the write-upFive confirmations, no action needed:
Two asks:
Extend the test to exercise the real call site, not just the constants.
|
|
Full reconciliation against our original 33-item review, everything raised since, and one earlier item — checked directly against code at the current head ( Confirmed fixed / resolved / correctly withdrawn — no action needed (click to expand)
Still open Item 8: Ask: give the target's own USERAUTH response its own, separately-bounded timeout, independent of Item 15/18: two small key-material hygiene gaps. Ask: either reject Item 19: 13 more fixed-name temp-file instances beyond the two originally fixed. Ask: apply the same Item 22: Vault error bodies and host strings both still reach logs unescaped, at 7 sites (one more than originally counted). Confirmed the mechanism precisely: every affected site uses Ask: switch the listed sites from Item 24: the never-expiring-certificate test is still skipped. Same skip reason as when first raised. Worth knowing even if unskipped: its current assertions (exit code + absence of one string) wouldn't check Warpgate's own refusal message anyway, so unskipping it as-is wouldn't fully close the original gap. Ask: un-skip the test, and strengthen its assertions to check Warpgate's own refusal message (not just exit code) while doing so, so unskipping actually closes the gap rather than reopening a weak one. Item 28: Ask: delete it as redundant, or rewrite it to assert something Warpgate-specific. Item 29: the hostile-option-name terminal test still lacks a positive anchor. Every sibling test in the same file already uses the pattern ( Ask: add the same positive anchor the sibling tests already use. Item 31 (second half): the admin host-key-check endpoint's error sanitization has no test at all. Bonus finding while checking: the nearest existing test that could plausibly cover this is actually non-discriminating for it — it asserts a string that also appears in an unrelated error variant's Ask: add a test that actually exercises this endpoint's sanitization specifically — asserting that raw internal error text can't reach the admin API response, using a case the existing non-discriminating test doesn't cover. Item 33: the abort-branch ordering isn't actually fixed, and "harmlessly" undersells a real divergence. The Ask: reorder so the reason is captured before Mutation-matrix CI wiring: not in any Ask: run it in CI (even just Item 17 (residual): I-10's test guards the buffer-growth helper, not the real call site (already self-disclosed honestly). Plus two smaller residual unzeroized paths found while checking: Ask: exercise Doc nits: README's Ask: fix the README lead-in to say "Four cases"; rewrite the Commit attribution: two small mismatches (I-4, I-6) — content's right, just cited to the wrong commit (both actually landed in Ask: repoint the references if it matters for your own tracking — not required, content is correct either way. One more precision correction. "A Vault role is validated when a target is saved" reads broader than what shipped. What's actually there is a syntactic name-format check, shared between the admin save path and the connect-time path — real, and a genuine improvement, but not a check that the role exists in Vault. Ask: narrow the claim to "the same name-format rule now applies at save time and connect time" — the current phrasing implies more than it delivers. |
|
One more thing, unrelated to the code itself. An earlier comment on this PR (issuecomment-5271487696) included a process suggestion — a 'maker-check' pipeline (separate RED test-builder/verifier, GREEN implementer/verifier, and deterministic re-checks run by orchestration rather than folded into any subagent's own judgment, plus multi-provider adversarial review before merge). It hasn't come up since, so mentioning it again in case it got lost in everything else this thread covered — no pressure either way, just didn't want it to go unseen. A recurring pattern across this review was tests that turned out not to exercise what they were named for — not a guarantee every one of them would've passed with the guard removed, but enough of a pattern that it seems worth naming. Some of that may be exactly what the pipeline above is aimed at. Some of it might just be the sheer size of this effort — a diff and a review this large probably pushes against context limits on both sides, which can cause the same kind of thing independent of process. Likely some mix of both, but wanted to flag that there's something structural here worth thinking about, beyond any individual finding. |
The duplicate-entry check added after a repeated `DISCRIMINATES` key silently won caught a repeated key — this one, byte-identical to its neighbour, both naming the same test. So the file as committed exited before running anything, and the reviewer who tried it had to work around that to get a number. The docstring also led with the expensive mode. Without `--named` the script asks which of *all* the tests notice a mutation, rerunning the whole integration suite and every crate's unit tests per guard; `--named` runs only the test named after the guard, twice. The published coverage number comes from the second, so that is the one the usage block now shows first.
…ution
Two causes, between them, made eleven guards report as not discriminating in a
reviewer's environment. Neither was an application defect, and both are the same
shape: the test harness reaching outside itself for something it could carry.
The sshd container was handed `-v {os.getcwd()}/ssh-keys:/ssh-keys`, a host path
assembled from wherever pytest happened to start. A Docker setup that
allow-lists which host directories may be shared mounts that as empty rather
than refusing, so sshd came up with no host key and every target container died
before the test it was started for could say why. The shared host key is now
copied into the per-server directory that was already being mounted, and the
second mount is gone.
Targets were configured as `localhost`. Warpgate resolves that and dials the
first address it gets, which on a dual-stack host is `::1`, while the containers
publish on v4 only. That failure is worse than a red test: a guard-disabled run
fails for the same reason a guard-enabled one does, which reads as the guard
being caught. The fakes in this suite were already made dual-stack for this
reason — an instance fix, since nothing can make a published Docker port answer
on `::1`. Targets now name `127.0.0.1`.
The matrix documented a command that does not run: `poetry -C tests run`
executes with the working directory already changed to `tests/`, at which point
the `tests` package is not importable and `-m` fails. Found while producing
evidence that the previous commit worked, which is the only reason it was found.
It also paid the whole repository's collection cost for a single guard — a
pytest collection plus one `cargo test --list` per crate, before any mutation.
Twenty-six minutes without reaching a verdict, measured. Collection is now
scoped to the selected guards' crates, and a name missing from that narrow set
is still looked for everywhere before it is called missing.
The hostile-option-name terminal test asserted only that no escape sequence
reached the terminal, which a connection dying before it wrote anything
satisfies too. It now anchors on the refusal and on the option name, so the
three cases it could not previously tell apart are separated.
… be forged The key ID is the whole point of this feature: it puts the Warpgate user's name in the target's own sshd log, so a session is attributable to a person. It was built by replacing `:` with `_`, which maps `root:admin` and `root_admin` onto one field — the log then names someone who did not connect. Percent-encoded now, `%` first so a literal `%3A` cannot read back as a colon. The comment above it claimed the function *rejects* a colon. It never did. That sentence was also the reason given for `UNATTRIBUTED` being safe from collision, and that reasoning was doubly wrong: what threatens `UNATTRIBUTED` is a user named `unattributed`, which colons have nothing to do with, and `user_key_id_field` held only `TOKEN_ATTRIBUTIONS` — two lists of reserved names, one consulted. Both now go through `is_reserved_key_id_field`, so a third name cannot be added to only one place. The existing colon test asserted `fields[1] == "root_admin"`, pinning the collision as correct behaviour. It counted fields, which a substitution mapping two people onto one name satisfies perfectly. Both new guards were A/B'd by hand rather than argued: with the encoding reverted `two_usernames_cannot_collide_in_a_key_id` fails, with the `UNATTRIBUTED` half of the predicate removed `a_username_cannot_impersonate_the_unattributed_placeholder` fails, and both pass restored. Separately, nine log sites carried a remote party's words through `Display`. A newline in a Vault error body, an unresolved host name or a certificate's option name forges a whole record in the default text format, indistinguishable from one Warpgate wrote. `Debug` escapes it. `emit_service_message` was the worst of them: it logs the message raw, before the PTY escaping that the same text goes through on its way to the terminal. Seven were reported; the other two are usernames from target configuration, which need admin access to exploit and are the same class, so they went too.
…the process Thirteen paths, not the two that were reported and fixed: eleven in the Vault client's tests and two in the zeroization test. Each was `wg-<label>-<pid>` under the system temp directory — one name per test rather than one per run, so two runs at once clobber each other, a crash leaves the file behind, and several of them hold a real credential under a name anyone who read the file can guess. A `tempfile::TempDir` per test now, removed when the test ends. `tempfile` was not a dev-dependency of this crate; it is one of the binary crate's, which is where the recordings-path fix took it from. Measured while verifying this: `cargo test -p warpgate-vault` spends 250 seconds in the library tests alone. The mutation matrix runs exactly that as its startup precondition, on every invocation, before it checks anything at all — so it is paid for a guard in any crate, and it is the largest single part of what makes the instrument read as too expensive to run.
The abort branch was reported fixed once already: the catch-all pattern was corrected and the ordering it was raised for was left in place. It still called `set_disconnected()` before returning, which sends `Done` ahead of the reason on the same channel — the exact ordering the `HandshakeTimeout` branch a few lines above avoids, and explains in its own comment why. The command loop's sibling matched `Some(())`, which is not another spelling of `_`: a closed `abort_rx` disables that branch instead of firing it, and with the event branch disabled too there is nothing left for `select!` to wait on. `role_id` is now `Secret<String>`. Vault calls it public and it is half a credential, but every other field in that enum that takes part in an authentication is redacted, and a rule is worth more than a judgement about which halves matter. `config-schema.json` is byte-identical — verified by regenerating it. The rationale is a `//` comment: as `///` it landed in the published schema as operator-facing documentation, which it is not. `error_body`'s buffer is `Zeroizing` and reserved at its own bound, so it neither grows nor survives unwiped. `read_bounded_json`'s `serde_json` scratch is not fixed and cannot be from here — those allocations are inside the parser. Recorded as a limit rather than closed. `test_token_zeroizing` is deleted. It built a `Zeroizing<String>` and asserted `as_str()` returned what was put in — the zeroize crate's own `Deref`, no Warpgate code in it, under a name that read as coverage of the token cache. The buffer-growth zeroization test now names what it does not cover: it drives the helper, not `read_bounded`, so a reader that stopped calling the helper would leave it green. Driving `read_bounded` needs a `reqwest::Response`, which would put hyper's buffering inside the window this file's allocator watcher measures, and the canary would be counted in someone else's freed memory. A test that fails for a cause we cannot fix is worse than one that says what it does not check. The comment claiming revocation is handled now says what happens: a `403` drops the cached token so the next call re-authenticates. That is cache invalidation. Warpgate never calls `revoke-self`, and the reason — the one moment it would is just after Vault refused the token, when the revoke would as likely be refused — lived only in a review thread.
Four commits: the beta bump, RDP clipboard redirection (warp-tech#2447), the duplicate admin role on a rerun of setup (warp-tech#2443), and the admin's new-tab preference (warp-tech#2396). Nothing they touch overlaps this branch's work — the one file in common, warpgate-protocol-ssh/src/client/mod.rs, they reformat a single line in, far from anything here.
`authentication_budget` covered the whole step, including the target's own USERAUTH reply, and for a certificate target it grows with `vault.timeout` — which config does not clamp from above. A target that went quiet the moment it received its certificate held the session, the ephemeral private key and a live certificate for a window measured in the issuer's slowness: 55 seconds by default and unbounded in principle. The target's answer now has a flat thirty-second bound of its own, applied at all five USERAUTH call sites so a new credential type cannot arrive unbounded by being forgotten, which is how this opened. `TargetAuthenticationTimeout` names the party that went quiet, the way `TunnelOpenTimeout` already does. The bound is a parameter rather than a constant read from a paused clock. The first version used tokio's `test-util` feature for that, which changes feature unification across the whole build: the A/B measuring it exceeded a fifteen minute cap without once reaching the test. A parameter costs a line. The admin host-key-check endpoint rendered `SSH protocol error` both for a host that could not be reached and for one whose key is not trusted. Same sentence, two entirely different jobs, and its caller is an authenticated operator rather than an untrusted party. `Io` and `russh::Error::IO` now render through `unreachable_reason(kind)` — the kind, never the operating system's own string, because this is the sanitiser and a fixed set of phrases cannot carry anything through it. That sanitiser had no test at all, and the nearest candidate asserts a string that also appears in an unrelated variant's `Display`, so it passes with the sanitising removed. Two now: one builds an error carrying `relation warpgate_user column password_hash` and requires it not to appear, the other requires unreachable and untrusted to differ and the reason still to be named. All three guards were A/B'd: each named test passes with its guard on and fails with it off. The runner was rewritten first — the previous one read the exit code, which cannot tell a caught guard from a killed runner, and restored only in `finally`, which a signal skips. It left a mutated source behind once. Repointing `key_id_field` for the earlier commit also left an existing guard anchored on a line that no longer exists. `check_anchors` refused and nothing else would have noticed; a guard with a stale anchor is reported as measured while never once being disabled.
`answering_a_host_key_question_puts_the_targets_own_bound_back` compared `once_the_host_key_is_answered()` against `HANDSHAKE_TIMEOUT` and called neither of the functions that move the deadline. It would have passed with either call site deleted, and with the two durations swapped between them — the two regressions it exists to catch. Raised externally, and "weaker than an end-to-end proof" undersold it: it did not touch the call site at all. The pause and the resume are now named functions with one implementation each, and the test holds the real `Sleep` the connect loop holds and moves it through them: the pause goes beyond a day, the answer shortens it, and what it comes back to is no longer than the target's own bound. The guard's anchor moved with it, from the constant to the call. On the constant it was mutating a value the test compared against another value, so the pair agreed with each other while nothing established that either was ever called. What this still does not prove is that the `select!` arm calls them. Nothing on that path can: russh does not invoke `check_server_key` until the key exchange completes, and the stalling fixture mutes before `NEWKEYS`, which was measured twice and is recorded beside the code.
…refused Un-skipping the integration test that had been parked on "holds the session open for ~45s for a reason not yet isolated" isolated the reason. It was a panic. `humantime`'s `Display` returns `Err` rather than truncating for any time at or after the year 9999, and `to_string()` panics when a `Display` errors. The validity window is rendered for the diagnostic message *above* the call to `certificate_mismatch`, so `ssh-keygen -V always:forever` — and equally a Vault role with no TTL, or one with an absurd `max_ttl` — killed the tokio worker mid-connection. Three consequences, all of them observed rather than reasoned about. The guard that refuses a never-expiring certificate could never run, because it sits after the line that panicked. The client was left holding a connection nobody would ever answer, which is the session-hold class again. And it is reachable by the issuer, which this design treats as hostile throughout. `describe_certificate_time` is now a function rather than a closure, formats through `write!`, and says so where humantime gives up. Its test drives the first second of the year 9999 and also asserts an ordinary expiry still renders as a date, so a fix that describes everything as unrenderable fails it. Nothing caught this because the unit test for the never-expiring guard calls `certificate_mismatch` directly and never reaches the panicking line, while the one test that did reach it was skipped on the symptom the panic produced. A test disabled because of the defect it had found. The integration test now passes end to end in seven seconds against a real sshd, asserting Warpgate's own refusal names the never-expiring window — not merely that the connection failed, which every other failure would satisfy too.
My own assertion from the previous round was wrong and the run said so:
`ssh-keygen` splits `critical:NAME=VALUE` on the first `=`, so the value is not
part of the name the refusal quotes. It asserted `HACKED=x`; the message carries
`HACKED`.
Correcting it exposed the weaker point behind it. "The option name is present"
and "no raw escape reached the terminal" are both satisfied by a fix that
silently strips the option name from the message. The escape now has to appear
in its inert form, `\u{1b}[2J`, so removal and escaping are told apart — which
is the only thing this test exists to distinguish.
Sixteen of sixteen in the hostile-certificate suite against a real sshd.
Until now "the guards discriminate" was a statement about the last afternoon somebody ran the matrix by hand and reported a number. Two of the numbers reported that way were wrong. A green check is the durable form of that claim. `--changed <base>` selects the guards whose anchor file a change touched, and prints how many of how many: a run that reports on a subset has to name the subset, or a green check implies a sweep that never happened. The workflow runs that subset on `pull_request` and all fifty-three on a schedule and on dispatch, the second being the backstop the first leans on. `fetch-depth: 0`, because `--changed` has nothing to diff against otherwise; the script names that case rather than reporting zero changed guards. A guard with no named discriminator fails the build, and every guard that did not discriminate is now named in the failure rather than only counted. The `cargo test -p warpgate-vault` precondition is skipped in `--named` mode. It runs that whole suite — 250 seconds, measured — on every invocation to establish the tree passes before anything is mutated, and `--named` establishes the same thing per guard and more narrowly: the named test must pass *before* the mutation, or the guard is reported `already failing` and no verdict comes out of it. A sweep also printed nothing until every guard had finished, so an hour-long run was indistinguishable from a hung one by anything except `ps`. Two of today's runs were killed for exactly that reason. Each guard now reports as it lands.
Two sweeps ran side by side today. The lock was written unconditionally rather than checked, so the second started happily beside the first and both rewrote the same source files — two mutations live at once, and any verdict either produced would have been about a tree neither of them described. It was noticed by counting processes, which is not a control. A lock left behind by a killed run now blocks a start too, and that is the right way round: a stale lock costs one command to clear, and the refusal names both the command and the check to run first. A contaminated sweep costs a number that looks like evidence. The pre-checks were also silent. They build a test binary per crate and run two workspace-wide `cargo check` passes, which together ran for over an hour on a full sweep while printing nothing at all — so the run was indistinguishable from a hung one, and three of today's were killed on exactly that ambiguity. Each phase now announces itself before it starts.
Raised by the auditor from the sweep's own output, which is the first defect this round found by watching an instrument run rather than by reading it. `verify_named` restored the mutated *source* in its `finally` and never rebuilt the binary compiled from it; the clean rebuild happened once, after the whole loop. So `target/debug/warpgate` carried the last mutation applied to it, and every integration guard's baseline ran the previous guard's mutated gateway. It surfaced as `already failing` on two guards of a sweep whose tree was clean. The consequence is wider than those two. A `discriminates` verdict asserts the named test passed before the mutation and failed after it, and the first half of that was measured against someone else's mutation. The verdicts from that sweep are void rather than partial, and it restarts rather than resumes. The gateway is now rebuilt from clean source before a baseline that needs one, and only then — which turns out to be 16 of the 53 guards. The other 37 are pinned by Rust unit tests, which `cargo test -p` compiles itself and which never look at `target/debug/warpgate`; building it for them was work whose result nothing read. Each guard now prints `baseline`, `building the gateway with the guard off`, and `testing with the guard off` as it reaches them, so a slow guard is legible as slow rather than as stuck. `--fail-fast` stops at the first guard that does not discriminate and says how many remain **unknown, not passing**. Guards added or repointed in round J are ordered first, so that flag reaches the least-established ones in minutes rather than hours. Order changes no verdict: every guard is measured against its own baseline.
The claim this feature's test suite has been asked to support since the first review round, produced in a single sweep rather than assembled from separate afternoons: each guard disabled in turn, and the test named after it failing exactly when the guard is gone and passing when it is not. `tests/mutation-matrix.json` carries `partial: false`, `refused: null`, and 53 results of one status. Fourteen hours and twenty minutes, of which roughly fifty-five minutes were pre-checks. The number is worth what the instrument is worth, and this round found six defects in the instrument. It would not start at all — its own duplicate check tripped on a duplicate. Its documented invocation ran from nowhere. It paid the whole repository's collection cost for a single guard, in three separate places. It printed nothing for hours, so three runs were killed as hung. Two runs executed side by side rewriting the same files, because the lock was written rather than checked. And the mutated gateway leaked into the next guard's baseline, which invalidated 28 verdicts of the preceding sweep — found by the auditor, from a running sweep's output, not from reading the code. All six were fixed before this run, and the run started from scratch rather than resuming, because a sweep half-measured against another guard's mutation is void rather than partial. Guards 29 and 31, the two that produced the false `already failing` that exposed the last of those defects, pass cleanly here. What this does not say: that these are the right guards, or that the set is complete. Neither claim is made.
Fourteen commits, of which the release itself, a vt100 upgrade fixing a resize panic, pubkey failures counting towards IP blocking, and a session view linking to its user and target. Only the two generated OpenAPI schemas conflicted, and they were regenerated from the merged Rust types rather than resolved by hand. That was not ceremony: taking either side wholesale would have dropped upstream's new `user_id`, `target_id` and `remote_address` fields out of the published API, silently, because a hand-resolved generated file corresponds to no version of the types it is generated from. `warpgate-protocol-ssh/src/server/session.rs` merged cleanly despite both sides editing it. No guard's anchor file is touched by any of it, so `--changed` selects nothing and the 53-guard sweep is not repeated. That rule cannot see a guard broken in a file the change did not touch — which is why the scheduled full sweep exists — so the integration suites are run instead, at ten minutes against fourteen hours.
|
@theredspoon Pushed — 46 commits, merged with v0.28.0, 0 behind main. Twenty-one of your twenty-five asks are closed and no product code is open. But the thing worth your time is not in your list. Un-skipping one test found a panicItem 24 asked me to un-skip
Three consequences, all observed rather than reasoned about:
Why nothing caught it: the unit test for that guard calls That is your closing observation, one level deeper than you put it. Fixed in Four claims of mine were wrong
Your listItem 8 — the target's USERAUTH answer has a flat 30s bound of its own, applied at all five call sites so a new credential type cannot arrive unbounded by being forgotten. New Item 15/18 — Item 19 — thirteen sites, all through Item 31 — Item 17 residual — Doc nits done. And you were right to narrow the role-validation claim: what shipped is a syntactic name-format rule shared by the save path and the connect path, not a check that the role exists. Item I-4/I-6 attribution: you are right, both landed in The mutation matrix, in CI and measured
With the honest cost attached, because it changes the recommendation. A full sweep is 14h20m on an M-series laptop. GitHub-hosted runners are 4 vCPU and slower, and the job limit is six hours, so the full sweep does not fit there at all. And the per-PR saving is smaller than it sounds: Six defects turned up in the instrument itself along the way. It would not start at all — its own duplicate check tripped on a duplicate that had been committed. Its documented invocation ran from nowhere. It paid the whole repository's collection cost for a single guard, in three separate places. It printed nothing for hours, so three runs were killed as hung. Two runs executed side by side rewriting the same files, because the lock was written rather than checked. And the mutated gateway leaked into the next guard's baseline, which voided 28 verdicts of a preceding sweep. 53 of 53 guards discriminate, measured in one run at Not done, deliberatelyUpstream logs client-controlled strings through Your process suggestionIt sat unanswered for six days and you were right to raise it again. It is now half-built, and not in the shape you drew. Rather than RED and GREEN subagents inside one process, the split is across two providers with hard role separation: I build and operate checks, an independent auditor specifies them and rules, and neither writes into the other's territory. Deterministic checks are run by orchestration, as you said — that is what the matrix is. It paid for itself in a way I would not have predicted. The auditor found the dirty-binary leak above — from a running sweep's output, not from reading the code. I had looked at the same two Everything green after the merge: 63 + 26 + 4 unit tests, 109 integration tests against a real sshd. |
…icate-auth # Conflicts: # Cargo.lock # warpgate-protocol-ssh/Cargo.toml
Warpgate authenticates to an SSH target with a short-lived OpenSSH user certificate signed on demand by HashiCorp Vault, instead of a private key it stores. The ephemeral keypair is generated per connection and never persisted, so a compromise of the Warpgate host yields nothing a target would accept.
Targets trust the CA through TrustedUserCAKeys and need no authorized_keys. The certificate's key ID carries the Warpgate username and session UUID, so the target's own sshd log attributes a proxied session to a person rather than to the gateway.
VaultAuth offers workload identity only — kubernetes, AppRole, AWS, Azure and GCP. Each reads its credential from a file or a metadata service, never from the config: a static Vault password would merely relocate the long-lived secret this feature exists to remove. Full compatibility with OpenBao is supported.
Verified end to end against real infrastructure — AWS STS, a GCE instance, an Azure VM and a k3d cluster.
tests/test_ssh_target_cert_auth.py runs against a stub issuer and needs neither Vault nor a cluster.
Discussion: #26
Special thanks to @theredspoon for the detailed test, OpenBao evaluation, and security recommendations.
Description
...
AI Usage
Choose the level of AI involvement for this PR.
This is not to block AI contributions but rather to speed up PR review (saves time on trying to deduce the logic behind AI hallucinations).